| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- 'use client';
- import './style.scss';
- import { usePathname, useSearchParams } from 'next/navigation';
- import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
- import { BoardLayout, BoardSort, PostSearchType } from '@/constants/forum';
- import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons';
- import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
- import { fetchApi } from '@/lib/utils/client';
- import useErrorAlert from '@/hooks/useErrorAlert';
- import Loading from '@/app/component/Loading';
- import Pagination from '@/app/component/Pagination';
- import { BoardResponse, BoardPostsResponse } from '@/types/response/forum/board';
- import Post from '@/types/forum/post';
- import useDragScroll from '@/hooks/useDragScroll';
- import PostWriteButton from '../_component/PostWriteButton';
- import NavTab from '@/app/(main)/support/navTab';
- import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
- import HeaderContent from '../_component/HeaderContent';
- import FooterContent from '../_component/FooterContent';
- import DefaultListLayout from '../_component/DefaultListLayout';
- import AlbumListLayout from '../_component/AlbumListLayout';
- import QnAListLayout from '../_component/QnAListLayout';
- type ViewProps = {
- _query: {
- page: number;
- perPage: number;
- prefix?: number;
- sort?: BoardSort;
- search: PostSearchType;
- keyword?: string;
- },
- _board: BoardResponse,
- _postList: BoardPostsResponse
- };
- export default function View({ _query, _board, _postList }: ViewProps)
- {
- const pathname = usePathname();
- const searchParams = useSearchParams();
- const { setError } = useErrorAlert();
- const [loading, setLoading] = useState<boolean>(false);
- const [total, setTotal] = useState<number>(_postList.total);
- const [speaker, setSpeaker] = useState<Post[]>(_postList.speaker);
- const [notice, setNotice] = useState<Post[]>(_postList.notice);
- const [list, setList] = useState<Post[]>(_postList.list);
- const [page, setPage] = useState<number>(_query.page);
- const [perPage, setPerPage] = useState<number>(_query.perPage);
- const [boardPrefixID, setBoardPrefixID] = useState<number|undefined>(_query.prefix);
- const [sort, setSort] = useState<BoardSort|undefined>(_query.sort);
- const [search, setSearch] = useState<PostSearchType>(_query.search);
- const [keyword, setKeyword] = useState<string|undefined>(_query.keyword);
- const [params, setParams] = useState<Record<string, string>>({});
- const [searchDialogOpen, setSearchDialogOpen] = useState(false);
- const isMounted = useRef(false);
- const searchRef = useRef(search);
- const keywordRef = useRef(keyword);
- const dragScroll = useDragScroll<HTMLElement>();
- searchRef.current = search;
- keywordRef.current = keyword;
- const startIndex = useMemo(() => total - ((page - 1) * perPage), [total, page, perPage]);
- // 상태 => URL 동기화
- useEffect(() => {
- // 기존 URL 파라미터
- const alreadyParams = new URLSearchParams(searchParams.toString());
- // URL 파라미터 덮어쓰기 및 삭제
- Object.entries(params).forEach(([k, v]) => {
- if (v) {
- alreadyParams.set(k, v);
- } else {
- alreadyParams.delete(k);
- }
- });
- const queryString = `?${alreadyParams.toString()}`;
- if (window.location.search !== queryString) {
- window.history.replaceState(null, '', `${pathname}${queryString}`);
- }
- }, [page, perPage, boardPrefixID, sort, search, keyword, params, pathname, searchParams]);
- const handleFetchPosts = useCallback(async () => {
- try {
- setLoading(true);
- const queryParams = new URLSearchParams();
- queryParams.set('boardID', String(_board.id));
- queryParams.set('page', String(page));
- queryParams.set('perPage', String(perPage));
- if (boardPrefixID) {
- queryParams.set('boardPrefixID', String(boardPrefixID));
- }
- if (sort !== undefined && sort !== null) {
- queryParams.set('sort', String(sort));
- }
- if (searchRef.current !== undefined && searchRef.current !== null) {
- queryParams.set('search', String(searchRef.current));
- }
- if (keywordRef.current) {
- queryParams.set('keyword', keywordRef.current);
- }
- const res = await fetchApi<BoardPostsResponse>(`/api/forum/posts?${queryParams.toString()}`);
- if (!res.data) {
- setError('게시글을 불러올 수 없습니다.');
- } else {
- setTotal(res.data.total);
- setSpeaker(res.data.speaker);
- setNotice(res.data.notice);
- setList(res.data.list);
- }
- } catch (err) {
- if (err instanceof Error) {
- setError(err.message || '알 수 없는 오류가 발생했습니다.');
- }
- } finally {
- setLoading(false);
- }
- }, [_board.id, boardPrefixID, page, perPage, sort]);
- const handlePageChange = useCallback((page: number) => {
- setPage(page);
- setParams((prev) => ({ ...prev, page: String(page) }));
- }, []);
- const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement>) => {
- const { name, value } = e.target;
- let key = '';
- switch (name) {
- case 'boardPrefixID':
- setBoardPrefixID((Number(value) || undefined) as number);
- key = 'prefix';
- break;
- case 'sort':
- setSort(Number(value) as BoardSort);
- break;
- case 'perPage':
- setPerPage(Number(value));
- break;
- case 'search':
- setSearch(Number(value) as PostSearchType);
- break;
- case 'keyword':
- setKeyword(value);
- break;
- }
- if (['sort', 'perPage', 'search', 'keyword'].includes(name)) {
- key = name;
- }
- if (['boardPrefixID', 'perPage', 'search', 'keyword'].includes(name)) {
- handlePageChange(1);
- }
- setParams((prev) => ({ ...prev, [key]: value }));
- }, [handlePageChange]);
- const handleSearch = useCallback((e: React.FormEvent) => {
- e.preventDefault();
- handleFetchPosts();
- }, [handleFetchPosts]);
- const handleSearchDialog = useCallback((e: React.FormEvent) => {
- e.preventDefault();
- handleFetchPosts();
- setSearchDialogOpen(false);
- }, [handleFetchPosts]);
- useEffect(() => {
- if (!isMounted.current) {
- isMounted.current = true;
- return;
- }
- handleFetchPosts();
- }, [page, perPage, boardPrefixID, sort, handleFetchPosts]);
- return (
- <>
- {_board.code === 'notice' && <NavTab currentTab='notice' />}
- <div id='board'>
- {loading && <Loading />}
- <HeaderContent isEnabled={_board.boardMeta.list.showHeader} content={_board.boardMeta.list.headerContent } />
- <div className='list-header'>
- {/* 말머리 */}
- <section aria-label='말머리 선택'>
- <h1>{ _board.name }</h1>
- <article ref={dragScroll.ref} onMouseDown={dragScroll.onMouseDown} onMouseMove={dragScroll.onMouseMove} onMouseUp={dragScroll.onMouseUp} onMouseLeave={dragScroll.onMouseLeave}>
- <ul>
- <li>
- <label {...(!boardPrefixID ? { className: 'active' } : {})}>
- <input type='radio' name='boardPrefixID' value='' checked={boardPrefixID === null} onChange={handleChange} /> 전체
- </label>
- </li>
- {_board.boardPrefix.map((row, i) => (
- <li key={i}>
- <label {...(boardPrefixID === row.id ? { className: 'active' } : {})}
- style={row.color && row.color !== '#000000' ? { background: row.color, color: '#f1f1f1' } : undefined}
- >
- <input type='radio' name='boardPrefixID' value={row.id} checked={boardPrefixID === row.id} onChange={handleChange}/>
- {row.name}
- </label>
- </li>
- ))}
- </ul>
- </article>
- </section>
- {/* 정렬 */}
- <section aria-label='게시글 정렬'>
- <select name='sort' value={sort ?? ''} title='게시글 정렬' onChange={handleChange}>
- <option value={BoardSort.CreatedAt}>최신순</option>
- <option value={BoardSort.Views}>조회순</option>
- <option value={BoardSort.Comments}>댓글순</option>
- <option value={BoardSort.Likes}>공감순</option>
- </select>
- </section>
- {/* 출력 수 */}
- <section aria-label='게시글 출력 수'>
- <select name='perPage' value={perPage} title='출력 수' onChange={handleChange}>
- <option value='10'>10개씩</option>
- <option value='20'>20개씩</option>
- <option value='30'>30개씩</option>
- <option value='50'>50개씩</option>
- <option value='100'>100개씩</option>
- </select>
- </section>
- </div>
- {/* 게시글 목록 */}
- {(() => {
- switch (_board.boardMeta.list.layout) {
- case BoardLayout.Media:
- return <AlbumListLayout boardListMeta={_board.boardMeta.list} speaker={speaker} notice={notice} list={list} startIndex={startIndex} onChange={setBoardPrefixID} />;
- case BoardLayout.QnA:
- return <QnAListLayout boardListMeta={_board.boardMeta.list} notice={notice} list={list} startIndex={startIndex} onChange={setBoardPrefixID} />;
- default:
- return <DefaultListLayout boardListMeta={_board.boardMeta.list} speaker={speaker} notice={notice} list={list} startIndex={startIndex} onChange={setBoardPrefixID} />;
- }
- })()}
- {/* 검색 */}
- <div className='list-footer'>
- {/* 모바일: 검색 아이콘 → Dialog */}
- <Dialog open={searchDialogOpen} onOpenChange={setSearchDialogOpen}>
- <DialogTrigger asChild>
- <button type='button' className='btn btn-default search-toggle' title='검색'>
- <FontAwesomeIcon icon={faMagnifyingGlass}/>
- </button>
- </DialogTrigger>
- <DialogContent className='w-[90%] sm:max-w-md'>
- <DialogHeader>
- <DialogTitle>게시글 검색</DialogTitle>
- </DialogHeader>
- <form onSubmit={handleSearchDialog} autoComplete='off' className='flex flex-col gap-3'>
- <select name='search' value={search ?? ''} title='검색 구분' onChange={handleChange} className='h-9 rounded-md border px-3'>
- <option value={PostSearchType.Subject}>제목</option>
- <option value={PostSearchType.Content}>내용</option>
- <option value={PostSearchType.Author}>작성자</option>
- <option value={PostSearchType.Comment}>댓글</option>
- </select>
- <input type='text' name='keyword' value={keyword} placeholder='검색어를 입력해주세요.' onChange={handleChange} className='h-9 rounded-md border px-3' />
- <button type='submit' className='btn btn-default w-full'>검색</button>
- </form>
- </DialogContent>
- </Dialog>
- {/* 데스크톱: 인라인 검색 폼 */}
- <section aria-label='게시글 검색'>
- <form onSubmit={handleSearch} autoComplete='off'>
- <select name='search' value={search ?? ''} title='검색 구분' onChange={handleChange}>
- <option value={PostSearchType.Subject}>제목</option>
- <option value={PostSearchType.Content}>내용</option>
- <option value={PostSearchType.Author}>작성자</option>
- <option value={PostSearchType.Comment}>댓글</option>
- </select>
- <input type='text' name='keyword' value={keyword} placeholder='검색어를 입력해주세요.' onChange={handleChange} />
- <button type='submit' className='btn btn-default'>검색</button>
- </form>
- </section>
- {/* 글쓰기 버튼 */}
- <PostWriteButton alwaysShowButton={_board.boardMeta.list.alwaysShowWriteButton} boardCode={_board.code} boardMeta={_board.boardMeta} boardManagers={_board.boardManager} />
- </div>
- <Pagination total={total} page={page} perPage={perPage} onChange={handlePageChange} />
- <FooterContent isEnabled={_board.boardMeta.list.showFooter} content={_board.boardMeta.list.footerContent } />
- </div>
- </>
- );
- }
|